<?php declare(strict_types=1);

namespace Nuwave\Lighthouse\Console;

use GraphQL\Language\AST\TypeDefinitionNode;
use GraphQL\Language\Parser;
use GraphQL\Type\Definition\Directive as DirectiveDefinition;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Introspection;
use GraphQL\Utils\SchemaPrinter;
use HaydenPierce\ClassFinder\ClassFinder;
use Illuminate\Console\Command;
use Nuwave\Lighthouse\Schema\AST\ASTCache;
use Nuwave\Lighthouse\Schema\AST\ASTHelper;
use Nuwave\Lighthouse\Schema\DirectiveLocator;
use Nuwave\Lighthouse\Schema\SchemaBuilder;
use Nuwave\Lighthouse\Schema\Source\SchemaSourceProvider;
use Nuwave\Lighthouse\Support\Contracts\Directive as DirectiveInterface;
use Symfony\Component\Console\Input\InputOption;

class IdeHelperCommand extends Command
{
    public const OPENING_PHP_TAG = /** @lang PHP */ "<?php declare(strict_types=1);\n\n";

    public const GENERATED_NOTICE = /** @lang PHP|GraphQL */ <<<'CODE'
# File generated by "php artisan lighthouse:ide-helper".
# Do not edit this file directly.
# This file should be ignored by git as it can be autogenerated.


CODE;

    protected $name = 'lighthouse:ide-helper';

    protected $description = 'Create IDE helper files to improve type checking and autocompletion.';

    /** @return array<int, array<int, mixed>> */
    protected function getOptions(): array
    {
        return [
            ['omit-built-in', null, InputOption::VALUE_NONE, 'Do not include built-in definitions.'],
        ];
    }

    public function handle(): int
    {
        $this->laravel->call([$this, 'schemaDirectiveDefinitions']);
        $this->laravel->call([$this, 'programmaticTypes']);
        $this->laravel->call([$this, 'phpIdeHelper']);

        $this->info("\nIt is recommended to add them to your .gitignore file.");

        return 0;
    }

    /** Create and write schema directive definitions to a file. */
    public function schemaDirectiveDefinitions(DirectiveLocator $directiveLocator): void
    {
        $schema = /** @lang GraphQL */ <<<'GRAPHQL'
"""
Placeholder type for various directives such as `@orderBy`.
Will be replaced by a generated type during schema manipulation.
"""
scalar _

GRAPHQL;

        $directiveClasses = $this->scanForDirectives(
            $directiveLocator->namespaces(),
        );

        $omitBuiltIn = $this->option('omit-built-in');
        $builtInDirectives = DirectiveDefinition::getInternalDirectives();
        foreach ($directiveClasses as $directiveClass) {
            $definitionString = $directiveClass::definition();

            $definitionNode = ASTHelper::extractDirectiveDefinition($definitionString); // Throws if the definition is invalid
            if ($omitBuiltIn && isset($builtInDirectives[$definitionNode->name->value])) {
                continue;
            }

            $trimmedDefinition = trim($definitionString);
            $schema .= /** @lang GraphQL */ <<<GRAPHQL

# Directive class: {$directiveClass}
{$trimmedDefinition}

GRAPHQL;
        }

        $filePath = static::schemaDirectivesPath();
        \Safe\file_put_contents($filePath, self::GENERATED_NOTICE . $schema);

        $this->info("Wrote schema directive definitions to {$filePath}.");
    }

    /**
     * Scan the given namespaces for directive classes.
     *
     * @param  array<string>  $directiveNamespaces
     *
     * @return array<string, class-string<\Nuwave\Lighthouse\Support\Contracts\Directive>>
     */
    protected function scanForDirectives(array $directiveNamespaces): array
    {
        $directives = [];

        foreach ($directiveNamespaces as $directiveNamespace) {
            /** @var array<class-string> $classesInNamespace */
            $classesInNamespace = ClassFinder::getClassesInNamespace($directiveNamespace);

            foreach ($classesInNamespace as $class) {
                $reflection = new \ReflectionClass($class);
                if (! $reflection->isInstantiable()) {
                    continue;
                }

                if (! is_a($class, DirectiveInterface::class, true)) {
                    continue;
                }

                $name = DirectiveLocator::directiveName($class);

                // The directive was already found, so we do not add it twice
                if (isset($directives[$name])) {
                    continue;
                }

                $directives[$name] = $class;
            }
        }

        return $directives;
    }

    /** @param  class-string<\Nuwave\Lighthouse\Support\Contracts\Directive>  $directiveClass */
    protected function define(string $directiveClass): string
    {
        $definition = $directiveClass::definition();

        // Throws if the definition is invalid
        ASTHelper::extractDirectiveDefinition($definition);

        return trim($definition);
    }

    public static function schemaDirectivesPath(): string
    {
        return base_path() . '/schema-directives.graphql';
    }

    /**
     * Users may register types programmatically, e.g. in service providers.
     * In order to allow referencing those in the schema, it is useful to print
     * those types to a helper schema, excluding types the user defined in the schema.
     */
    public function programmaticTypes(SchemaSourceProvider $schemaSourceProvider, ASTCache $astCache, SchemaBuilder $schemaBuilder): void
    {
        $sourceSchema = Parser::parse($schemaSourceProvider->getSchemaString());
        $sourceTypes = [];
        foreach ($sourceSchema->definitions as $definition) {
            if ($definition instanceof TypeDefinitionNode) {
                $sourceTypes[$definition->getName()->value] = true;
            }
        }

        $astCache->clear();

        $allTypes = $schemaBuilder->schema()->getTypeMap();

        if ($this->option('omit-built-in')) {
            $sourceTypes = array_merge($sourceTypes, Type::getStandardTypes(), Introspection::getTypes());
        }

        $programmaticTypes = array_diff_key($allTypes, $sourceTypes);

        $filePath = static::programmaticTypesPath();

        if ($programmaticTypes === [] && file_exists($filePath)) {
            \Safe\unlink($filePath);

            return;
        }

        $schema = implode(
            "\n\n",
            array_map(
                static fn (Type $type): string => SchemaPrinter::printType($type),
                $programmaticTypes,
            ),
        );

        \Safe\file_put_contents($filePath, self::GENERATED_NOTICE . "{$schema}\n");

        $this->info("Wrote definitions for programmatically registered types to {$filePath}.");
    }

    public static function programmaticTypesPath(): string
    {
        return base_path() . '/programmatic-types.graphql';
    }

    public function phpIdeHelper(): void
    {
        $filePath = static::phpIdeHelperPath();
        $contents = \Safe\file_get_contents(__DIR__ . '/../../_ide_helper.php');

        \Safe\file_put_contents($filePath, $this->withGeneratedNotice($contents));

        $this->info("Wrote PHP definitions to {$filePath}.");
    }

    public static function phpIdeHelperPath(): string
    {
        return base_path() . '/_lighthouse_ide_helper.php';
    }

    protected function withGeneratedNotice(string $phpContents): string
    {
        return substr_replace(
            $phpContents,
            self::OPENING_PHP_TAG . self::GENERATED_NOTICE,
            0,
            strlen(self::OPENING_PHP_TAG),
        );
    }
}
